RCSI 簡介與改良版 Entity Framework 鎖定提示攔截器
TLDR
- RCSI (Read Committed Snapshot Isolation) 是解決高併發讀寫衝突的現代化標準做法,透過版本控制避免讀取被寫入阻塞。
- RCSI 為資料庫層級設定,開啟後可取代大部分
WITH (NOLOCK)的使用場景。 - 若需精細控制 SQL Hint(如
NOLOCK或OPTIMIZE FOR UNKNOWN),建議使用 EF Core 的TagWith搭配DbCommandInterceptor進行顯式宣告,而非全域強制注入。 - 框架層級的自動化處理應以「降低團隊認知負荷」為核心,而非盲目追求技術純粹性。
RCSI 是什麼?
RCSI (Read Committed Snapshot Isolation) 透過「版本控制」解決鎖定問題。當資料被寫入時,讀取操作不會被阻塞,也不會讀到髒資料,而是讀取「寫入前的最後一個版本 (Snapshot)」。
RCSI 的特性與限制
- 全域生效:RCSI 是資料庫層級設定,開啟後會影響所有使用 Read Committed 隔離層級的查詢。
- 非全域開關:無法針對單一資料表開啟。若需選擇性使用,需改用 Snapshot Isolation 並在 Transaction 中顯式指定。
- 無法取代樂觀鎖:RCSI 僅解決讀取阻塞問題,資料更新時的併發衝突仍需搭配 RowVersion 機制。
常見鎖定機制比較
- WITH (NOLOCK):讀取可能包含尚未提交的「髒資料」。
- WITH (READPAST):遇到鎖定行直接跳過,適用於 Queue 處理,不適合報表。
- RCSI:讀取已提交的歷史快照,無需加 Hint 即可達成非阻塞讀取。
TIP
在現代硬體(SSD)與雲端環境(如 Azure SQL Database 預設開啟)下,RCSI 的 TempDB 負擔已不再是致命傷,是比到處加 NOLOCK 更乾淨的解法。
TagWith + Interceptor 改良實作
當專案不適合全域開啟 RCSI,或需針對特定查詢進行效能調校時,可利用 EF Core 的 TagWith 功能進行顯式宣告,透過攔截器動態注入 SQL Hint。
1. 定義擴充方法
透過 Fluent API 語法,讓程式碼意圖更清晰。
csharp
public static class EfHintExtensions {
public const string TagNoLock = "SQL_HINT: NOLOCK";
public const string TagReadPast = "SQL_HINT: READPAST";
public const string TagOptimizeForUnknown = "SQL_OPTION: OPTIMIZE FOR UNKNOWN";
public const string TagRecompile = "SQL_OPTION: RECOMPILE";
public static IQueryable<T> WithNoLock<T>(this IQueryable<T> query) => query.TagWith(TagNoLock);
public static IQueryable<T> WithReadPast<T>(this IQueryable<T> query) => query.TagWith(TagReadPast);
public static IQueryable<T> WithOptimizeForUnknown<T>(this IQueryable<T> query) => query.TagWith(TagOptimizeForUnknown);
public static IQueryable<T> WithRecompile<T>(this IQueryable<T> query) => query.TagWith(TagRecompile);
}2. 實作攔截器 (Interceptor)
此攔截器支援 Schema 格式,並能自動合併多個 Query Option。
csharp
public class SqlTaggingInterceptor : DbCommandInterceptor {
private static readonly RegexOptions regexOptions = RegexOptions.Multiline | RegexOptions.IgnoreCase;
private static readonly Regex tableAliasRegex = new(
@"(?<tableAlias>(?:FROM|JOIN)\s+(?:\[[^\]]+\]\.)?\[[^\]]+\]\s+AS\s+\[[^\]]+\])(?!\s+WITH\s*\()",
regexOptions
);
public override InterceptionResult<DbDataReader> ReaderExecuting(
DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result
) {
FixCommand(command);
return base.ReaderExecuting(command, eventData, result);
}
private static void FixCommand(DbCommand command) {
if (string.IsNullOrWhiteSpace(command.CommandText)) return;
string text = command.CommandText;
bool isChanged = false;
string hintToApply = null;
if (text.Contains(EfHintExtensions.TagNoLock)) hintToApply = "WITH (NOLOCK)";
else if (text.Contains(EfHintExtensions.TagReadPast)) hintToApply = "WITH (READPAST)";
if (hintToApply != null) {
text = tableAliasRegex.Replace(text, $"${{tableAlias}} {hintToApply}");
isChanged = true;
}
List<string> options = new ();
if (text.Contains(EfHintExtensions.TagOptimizeForUnknown)) options.Add("OPTIMIZE FOR UNKNOWN");
if (text.Contains(EfHintExtensions.TagRecompile)) options.Add("RECOMPILE");
if (options.Count > 0) {
text = text.TrimEnd().TrimEnd(';');
text += $" OPTION ({string.Join(", ", options)});";
isChanged = true;
}
if (isChanged) command.CommandText = text;
}
}實際使用範例
csharp
// 報表:允許髒讀 + 解決參數嗅探
List<Order> orders = context.Orders
.WithNoLock()
.WithOptimizeForUnknown()
.ToList();
// Queue:跳過鎖定行
Job job = context.Jobs
.WithReadPast()
.FirstOrDefault();TIP
本篇完整可執行範例請參考:CloudyWing/EfCoreSqlHintInterceptorSample。
結論與建議
- 優先採用 RCSI:在讀多寫少的系統中,RCSI 是最乾淨的預設解法,能從根本解決讀寫衝突。
- 顯式宣告優於隱式注入:使用
TagWith進行顯式宣告,能讓開發者清楚掌握哪些查詢使用了特殊的 SQL Hint,降低維護風險。 - 架構設計考量:全域處理(如
AsNoTracking或攔截器)的價值在於降低團隊的認知負荷。應評估專案情境,選擇「預設行為」與「顯式介入」之間的平衡點,而非盲目排斥框架層級的自動化。
異動歷程
- 初版文件建立。
- 將擴充方法
WithOptionUnknown更名為WithOptimizeForUnknown,與範例專案一致。 - 補上對應 GitHub 範例專案連結。
- 將擴充方法